﻿/**
 * Copyright 2010 Dave Keen
 * http://www.actionscriptdeveloper.co.uk
 * 
 * This file is part of Flextrine.
 * 
 * Flextrine is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 * 
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 * 
 * You should have received a copy of the GNU General Public License
 * and the Lesser GNU General Public License along with this program.
 * If not, see <http://www.gnu.org/licenses/>.
 * 
 */

package org.davekeen.flextrine.orm {
	import flash.events.EventDispatcher;
	import flash.utils.Dictionary;
	import mx.collections.ArrayCollection;
	import mx.logging.Log;
	import mx.logging.LogEventLevel;
	import mx.logging.targets.TraceTarget;
	import mx.rpc.AsyncToken;
	import mx.rpc.events.ResultEvent;
	import org.davekeen.flextrine.orm.delegates.FlextrineDelegate;
	import org.davekeen.flextrine.orm.events.FlextrineEvent;
	import org.davekeen.flextrine.orm.metadata.MetaTags;
	import org.davekeen.flextrine.orm.rpc.FlextrineAsyncResponder;
	import org.davekeen.flextrine.util.ClassUtil;
	import org.davekeen.flextrine.util.EntityUtil;
	import org.davekeen.flextrine.util.QueryUtil;
	import mx.core.mx_internal;
	
	// Required for token.applyResult
	use namespace mx_internal;
	
	/**
	 * ...
	 * @author Dave Keen
	 */
	public class EntityManager extends EventDispatcher {
		
		private static var _instance:EntityManager;
		private static var singletonInstantiation:Boolean;
		
		private var repositories:Dictionary;
		
		private var unitOfWork:UnitOfWork;
		
		private var flextrineDelegate:FlextrineDelegate;
		
		// The configuration of this EntityManager
		private var configuration:Configuration;
		
		// This object is used to track cyclical references and ensure we don't get into an inifinite loop when adding heirarchical objects to the repositories
		private var visited:Object;
		
		public function EntityManager() {
			// Ensure singleton integrity
			if (!singletonInstantiation)
				throw new Error("EntityManager is a singleton and can only be accessed through getInstance()");
			
			// Configure logging for use within Flextrine
			var logTarget:TraceTarget = new TraceTarget();
			logTarget.filters = [ "org.davekeen.*" ];
			logTarget.level = LogEventLevel.ALL;
			logTarget.includeDate = false;
			logTarget.includeTime = false;
			logTarget.includeCategory = true;
			logTarget.includeLevel = true;
			Log.addTarget(logTarget);

			unitOfWork = new UnitOfWork(this);
			repositories = new Dictionary(true);
			
			// Create the delegate for RPC calls to PHP.  Since other programs can hook into these events we need to give these maximum priority as there
			// are Flextrine specific tasks (e.g. populating the repositories) that need to be done before anything else.  Use a priority of int.MAX_VALUE
			// to achieve this.
			flextrineDelegate = new FlextrineDelegate();
			flextrineDelegate.addEventListener(FlextrineEvent.LOAD_COMPLETE, onFlextrineLoadComplete, false, int.MAX_VALUE, false);
			flextrineDelegate.addEventListener(FlextrineEvent.FLUSH_COMPLETE, onFlextrineFlushComplete, false, int.MAX_VALUE, false);
		}
		
		/**
		 * Get a singleton instance of the EntityManager.
		 * 
		 * @return
		 */
		public static function getInstance():EntityManager {
			if (!_instance) {
				singletonInstantiation = true;
				_instance = new EntityManager();
				singletonInstantiation = false;
			}
			
			return _instance;
		}
		
		/**
		 * Get direct access to the unit of work.  Public access to this might be removed in the future as, unlike Doctrine 2, changing
		 * things directly in the UoW can potentially put Flextrine in an unstable state.
		 * 
		 * @return
		 */
		public function getUnitOfWork():UnitOfWork {
			return unitOfWork;
		}
		
		/**
		 * @private 
		 * @return
		 */
		internal function getDelegate():FlextrineDelegate {
			return flextrineDelegate;
		}
		
		/**
		 * Set the configuration of the EntityManager.  At least configuration.gateway must be set to use Flextrine.
		 * 
		 * @param	configuration
		 */
		public function setConfiguration(configuration:Configuration):void {
			this.configuration = configuration;
			
			flextrineDelegate.gateway = configuration.gateway;
			flextrineDelegate.service = configuration.service;
		}
		
		/**
		 * Get the configuration.
		 * 
		 * @return
		 */
		public function getConfiguration():Configuration {
			return configuration;
		}
		
		public function persist(entity:Object):Object {
			// Add the entity to the appropriate repository, and receive a temporary id back
			var temporaryUid:String = (getRepository(ClassUtil.getClass(entity)) as EntityRepository).persistEntity(entity);
			
			// If the entity was already persisted we get null back and do nothing
			if (temporaryUid) {
				// Add the persist operation to the unit of work
				unitOfWork.persist(entity, temporaryUid);
				
				// Return the entity to aid in the chaining methods together
				return entity;
			} else {
				return null;
			}
		}
		
		/**
		 * Convert the managed entity to a detached entity.  This entity is not the same instance as that in the repository and
		 * changes to its properties will not trigger updates on the database.
		 * 
		 * @param	entity
		 * @return
		 */
		public function detach(entity:Object):Object {
			return EntityUtil.copyEntity(entity);
		}
		
		/**
		 * Merge a detached entity back into Flextrine.  If the entity has changed compared to its merged counterpart the merged
		 * object will be marked dirty and scheduled for update on the next flush.
		 * 
		 * @private
		 * 
		 * @param	entity
		 * @return  The managed entity
		 */
		public function merge(entity:Object):Object {
			return addLoadedEntityToRepository(entity, true);
		}
		
		/**
		 * Remove an entity from the repository and mark it for deletion from the database on the next flush.
		 * 
		 * @param	entity
		 * @return
		 */
		public function remove(entity:Object):Object {
			if ((getRepository(ClassUtil.getClass(entity)) as EntityRepository).deleteEntity(entity))
				unitOfWork.remove(entity);
			
			// Return the entity to aid in the chaining methods together
			return entity;
		}
		
		/**
		 * Run a DQL select query against the database.  Use EntityManager.getDQLClass to convert a Flextrine entity or entity class
		 * into a DQL fully qualified class name.
		 * 
		 * @param	query
		 * @param	fetchMode
		 * @return
		 */
		public function select(query:String, fetchMode:String = null):AsyncToken {
			return getDelegate().select(query, (fetchMode) ? fetchMode : getConfiguration().fetchMode);
		}
		
		/**
		 * Run a DQL select query against the database.  Use EntityManager.getDQLClass to convert a Flextrine entity or entity class
		 * into a DQL fully qualified class name.  Only returns the first results - all others are discarded on the server.
		 * 
		 * @param	query
		 * @param	fetchMode
		 * @return
		 */
		public function selectOne(query:String, fetchMode:String = null):AsyncToken {
			return getDelegate().selectOne(query, (fetchMode) ? fetchMode : getConfiguration().fetchMode);
		}
		
		/**
		 * Flush all changes against the database.
		 * 
		 * @param	fetchMode
		 * @return
		 */
		public function flush(fetchMode:String = null):AsyncToken {
			return unitOfWork.flush((fetchMode) ? fetchMode : getConfiguration().fetchMode);
		}
		
		/**
		 * Call a custom method on the Flextrine service.
		 * 
		 * @param	methodName
		 * @param	...args
		 * @return
		 */
		public function callRemoteMethod(methodName:String, ...args):AsyncToken {
			return getDelegate().callRemoteMethod(methodName, args);
		}
		
		/**
		 * Ensure that a single valued association is loaded before taking an action.  If the association is already loaded this
		 * calls onResult instantly.
		 * 
		 * @param	entity
		 * @param	onResult
		 * @param	onFault
		 * @return
		 */
		public function requireOne(entity:Object, onResult:Function = null, onFault:Function = null):AsyncToken {
			if (entity is ArrayCollection)
				throw new FlextrineError("requireOne can only be called on a single valued association, not a collection", FlextrineError.ILLEGAL_REQUIRE);
				
			if (EntityUtil.isInitialized(entity)) {
				if (onResult != null) onResult();
				// TODO: This should return an AsyncToken that initializes instantly
				return null;
			} else {
				var asyncToken:AsyncToken = getRepository(ClassUtil.getClass(entity)).loadOneBy(EntityUtil.getIdObject(entity));
				if (onResult != null || onFault != null) asyncToken.addResponder(new FlextrineAsyncResponder(onResult, onFault));
				return asyncToken;
			}
		}
		
		/**
		 * Ensure that a many valued collection is loaded before taking an action.  If the collection is already loaded this
		 * calls onResult instantly.
		 * 
		 * @param	entity
		 * @param	onResult
		 * @param	onFault
		 * @return
		 */
		public function requireMany(parentEntity:Object, manyAttributeName:String, onResult:Function = null, onFault:Function = null):AsyncToken {
			if (!(parentEntity[manyAttributeName] is ArrayCollection))
				throw new FlextrineError("requireMany can only be called on a collection", FlextrineError.ILLEGAL_REQUIRE);
			
			if (EntityUtil.isCollectionInitialized(parentEntity, manyAttributeName)) {
				if (onResult != null) onResult();
				// TODO: This should return an AsyncToken that initializes instantly
				return null;
			} else {
				// Build up the fetch query to refetch the parentEntity but fetch joined to the association we are requiring
				var query:String = "SELECT p, a FROM ";
				query += QueryUtil.getDQLClass(parentEntity) + " p JOIN p." + manyAttributeName + " a ";
				query += "WHERE p.id=" + parentEntity.id;
				
				var asyncToken:AsyncToken = selectOne(query);
				if (onResult != null || onFault != null) asyncToken.addResponder(new FlextrineAsyncResponder(onResult, onFault));
				return asyncToken;
			}
		}
		
		/**
		 * Get the EntityRepository for the given class.  If the repository does not exist then create an empty one and return it.
		 * 
		 * @param	entityClass
		 * @return
		 */
		public function getRepository(entityClass:Class):IEntityRepository {
			if (!repositories[entityClass])
				repositories[entityClass] = new EntityRepository(this, entityClass);
			
			return repositories[entityClass] as IEntityRepository;
		}
		
		/**
		 * This empties the repositories and clears and scheduled operations.
		 */
		public function clear():void {
			for each (var repository:EntityRepository in repositories)
				repository.clear();
			
			unitOfWork = new UnitOfWork(this); // TODO: This should probably call unitOfWork.clear() instead
		}
		
		private function onFlextrineLoadComplete(e:FlextrineEvent):void {
			// If we got nothing back then no changes are required on the server so we don't need to do anything
			if (!e.data) return;
			
			// Convert the result to an array (even if it is a single entity returned from load())
			var entities:Array = (e.data is Array) ? e.data as Array : [ e.data ];
			
			// Loading objects that are already in the repository results in a merge which means that we can't just return e.data to any responders that were
			// added to the original load call.  Instead we need to build up a new result (which will be an array or an array of entities) and replace the
			// result in the AsyncToken with it.
			var result:Object;
			
			// Add everything to the repository filling in result along the way
			if (e.data is Array) {
				result = new Array();
				// If we have an array its a special case and we want to maintain the cyclical reference visited array between
				// calls to addLoadedEntityToRepository, so clear it here and use doAddLoadedEntityToRepository directly
				visited = new Object();
				for each (var entity:Object in entities)
					result.push(doAddLoadedEntityToRepository(entity));
			} else {
				result = addLoadedEntityToRepository(e.data);
			}
			
			// Re-apply the result to the token.  We need to remove all responders added by Flextrine (FlextrineAsyncResponders) which will only leave
			// user added ones.
			var resultEvent:ResultEvent = e.resultEvent;
			
			for (var flextrineResponderCount:uint = 0; flextrineResponderCount < resultEvent.token.responders.length; flextrineResponderCount++)
				if (!(resultEvent.token.responders[flextrineResponderCount] is FlextrineAsyncResponder))
					break;
			
			resultEvent.token.responders.splice(0, flextrineResponderCount);
			resultEvent.token.applyResult(new ResultEvent(ResultEvent.RESULT, resultEvent.bubbles, resultEvent.cancelable, result, resultEvent.token, resultEvent.message));
		}
		
		/**
		 * This adds an entity to the applicable EntityRepositories by recursively adding the object itself, followed by its [MappedAssociations].
		 * Once this method has run the repositories will have been populated with the entity tree in the given entity allowing find, findBy and
		 * findAll operations to run locally for each repository.  Note that this method tracks cyclical references so we don't end up in an infinite
		 * loop.
		 * 
		 * @param	entity  The entity to add to the repository
		 */
		private function addLoadedEntityToRepository(loadedEntity:Object, checkForPropertyChanges:Boolean = false):Object {
			// Clear the cyclical reference tracker
			visited = new Object();
			
			return doAddLoadedEntityToRepository(loadedEntity, checkForPropertyChanges);
		}
		
		private function doAddLoadedEntityToRepository(loadedEntity:Object, checkForPropertyChanges:Boolean = false):Object {
			// Add the entity itself to the appropriate repository
			if (!visited[EntityUtil.getUniqueHash(loadedEntity)]) {
				// Check if this entity already exists in its repository
				var repository:EntityRepository = getRepository(ClassUtil.getClass(loadedEntity)) as EntityRepository;
				var foundEntity:Object = repository.findOneBy(EntityUtil.getIdObject(loadedEntity));
				
				var repoEntity:Object;
				
				if (!foundEntity) {
					// If the entity doesn't already exist then add it
					repoEntity = (getRepository(ClassUtil.getClass(loadedEntity)) as EntityRepository).addEntity(loadedEntity);
				} else {
					// Update the existing entity with the values from the entity that was just loaded
					repoEntity = repository.updateEntity(loadedEntity, checkForPropertyChanges);
				}
				
				// Add the entity to the list of visited entities to ensure we don't add the same entity twice
				visited[EntityUtil.getUniqueHash(loadedEntity)] = true;
			
				// Go through the associations adding or remapping as appropriate.  If the entity is a lazily loaded stub we don't want to do this.
				if (EntityUtil.isInitialized(loadedEntity)) {
					for each (var associationAttribute:XML in EntityUtil.getAttributesWithTag(loadedEntity, MetaTags.ASSOCIATION)) {
						// Cascade through the owning (mapped) associations calling doAddLoadedEntityToRepository on each associated entity.
						var value:* = loadedEntity[associationAttribute];
						
						if (value is ArrayCollection) {
							// The association is a collection
							
							// If the repoEntity is a different instance to the loadedEntity (i.e. we found a matching one in the repository) and
							// this collection is initalized in the loadedEntity, we want to totally replace the collection in the repoEntity so
							// delete anything that is there.
							if (loadedEntity !== repoEntity && EntityUtil.isCollectionInitialized(loadedEntity, associationAttribute))
								repoEntity[associationAttribute].removeAll();
							
							var repoEntityRepository:EntityRepository = getRepository(ClassUtil.getClass(repoEntity)) as EntityRepository;
							
							for (var n:uint = 0; n < value.source.length; n++) {
								var associatedObject:Object = value.source[n];
								if (!visited[EntityUtil.getUniqueHash(associatedObject)])
									// If the repoEntity is a different instance to the loadedEntity we are replacing the collection (which was just
									// deleted) so use addItem), otherwise they are the same instances so we don't use add.
									if (loadedEntity !== repoEntity) {
										// We only add related entities to a many association if they have not already been added (as they can be in
										// a ManyToMany relationship) and the collection is initialized.
										if ((loadedEntity !== repoEntity || checkForPropertyChanges) && EntityUtil.isCollectionInitialized(loadedEntity, associationAttribute))
											// Add the result of the recursion to repoEntity[associationAttribute].  We use a method in EntityRepository
											// so that the change listeners can be disabled during the update.
											repoEntityRepository.addEntityToManyAssociation(repoEntity, associationAttribute, doAddLoadedEntityToRepository(associatedObject, checkForPropertyChanges), checkForPropertyChanges);
									} else {
										// Otherwise they are the same instance so we just add it to the repository without adding it to repoEntity
										// (since repoEntity IS loadedEntity so its already there!)
										doAddLoadedEntityToRepository(associatedObject, checkForPropertyChanges);
									}
							}
						} else if (value is Object) {
							// The association is single valued
							repoEntity[associationAttribute] = doAddLoadedEntityToRepository(value, checkForPropertyChanges);
						} else {
							// Don't add a null attribute
						}
					}
				}
				
				// Return the entity we just added to the repository
				return getRepository(ClassUtil.getClass(loadedEntity)).findOneBy(EntityUtil.getIdObject(repoEntity));
			} else {
				// If we have already visited the entity then just return it directly
				return getRepository(ClassUtil.getClass(loadedEntity)).findOneBy(EntityUtil.getIdObject(loadedEntity));
			}
		}
		
		private function onFlextrineFlushComplete(e:FlextrineEvent):void {
			// Clear the unit of work
			unitOfWork.clear();
			
			var changeSet:ChangeSet = new ChangeSet(e.data);
			executeChangeSet(changeSet);
		}
		
		private function executeChangeSet(changeSet:ChangeSet):void {
			var oid:String;
			
			// Entity insertions are a special case - we need to match up the returned entity with the id-less entity currently in the repository.  To do this
			// we pass around a temporary identifier generated when we persisted the object.  addPersistedEntity takes care of matching up the entity with the
			// existing entity in the repository and merging it (or creating a new entity if it was not generated on this client).
			for (oid in changeSet.entityInsertions)
				(getRepository(ClassUtil.getClass(changeSet.entityInsertions[oid])) as EntityRepository).addPersistedEntity(changeSet.entityInsertions[oid], changeSet.temporaryUidMap[oid]);
			
			for (oid in changeSet.entityUpdates)
				(getRepository(ClassUtil.getClass(changeSet.entityUpdates[oid])) as EntityRepository).updateEntity(changeSet.entityUpdates[oid]);
			
			// When the deletions changeset is called on the client that actually performed the delete, obviously the entity will no longer be in that
			// repository and deleteEntity will throw an 'entity not found' exception.  Therefore we only delete from the repository if the
			// entity is not in this clients removedEntities map.  If it *is* in the map we just remove it from the map.
			for (oid in changeSet.entityDeletions) {
				if (!unitOfWork.hasRemovedEntity(changeSet.entityDeletions[oid])) {
					(getRepository(ClassUtil.getClass(changeSet.entityDeletions[oid])) as EntityRepository).deleteEntity(changeSet.entityDeletions[oid]);
				} else {
					unitOfWork.removeEntityFromRemoveMap(changeSet.entityDeletions[oid]);
				}
			}
			
			// At the end of a flush we would expect the unit of work to no longer have any expected entity removals in its map
			if (unitOfWork.hasRemovedEntities())
				throw new Error("Unit of work still contains expected removals after a flush");
		}
		
	}

}